<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>YaCy '#[clientname]#': Chat</title>
    #%env/templates/metas.template%#
    <link rel="stylesheet" href="js/styles/a11y-dark.min.css" type="text/css" />
    <style type="text/css">
      /* 1. Global Container */
      .chat-panel {
        width: 100%;
        color: #333333;
        padding: 0 0 32px 0; 
      }
    
      /* 2. Layout Structure */
      .chat-flow {
        display: flex;
        flex-direction: column;
        gap: 15px;
      }
      
      .chat-messages {
        display: flex;
        flex-direction: column;
        gap: 10px;
      }
    
      /* 3. The Message Blocks - Structural Look */
      .chat-turn {
        position: relative;
        border: 1px solid #e0e0e0;
        border-radius: 0px; 
        padding-top: 2px;
        padding-bottom: 12px;
        padding-left: 20px;
        padding-right: 20px;
        margin: 0;
        box-shadow: 2px 2px 0px rgba(0,0,0,0.1);
        transition: all 0.2s;
      }
      .chat-turn:hover {
        box-shadow: 3px 3px 0px rgba(0,0,0,0.15); 
      }
    
      /* 4. Labels (User/Assistant Headers) - Headline Font Consistency */
      .chat-turn legend {
        font-family: inherit; 
        padding: 4px 10px;
        font-size: 0.95rem;
        font-weight: 700;
        letter-spacing: 0.5px;
        text-transform: uppercase;
        border: 1px solid transparent;
        margin-bottom: 8px; /* Increased spacing */
      }
    
      /* --- USER: Lighter Grey Input --- */
      .chat-turn.user {
        background-color: #ECF1F8;
        border-left-width: 5px;
        border-left-style: solid;
        border-color: #d1d9e6;
        color: #2c3e50;
      }
      
      .chat-turn.user legend {
        background: #5092CF; 
        color: #ffffff;
        border: 1px solid #5092CF;
      }
    
      /* --- ASSISTANT: Darker Grey Output --- */
      .chat-turn.assistant {
        background-color: #DEE7F3;
        border-left-width: 5px;
        border-left-style: solid;
        border-color: #bbccdd;
        color: #000;
      }

      .chat-turn.assistant legend {
        background: #2c3e50; 
        color: #ffffff;
        border: 1px solid #2c3e50;
      }
    
      /* --- SYSTEM: Log Entry Style --- */
      .chat-turn.system {
        background: #fff3cd; 
        border: 1px solid #ffeeba;
        border-left: 5px solid #ffc107;
        color: #856404;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        font-size: 1.2rem;
      }
    
      /* 5. Typography - All Chat Content is MONOSPACE */
      .chat-body {
        line-height: 1.6;
        font-size: 1.2rem; /* Larger font size */
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
      }

      .chat-body.plain-text {
        white-space: pre-wrap;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
      }

      .chat-body.markdown {
        white-space: normal;
      }

      .chat-body.markdown,
      .chat-body.markdown * {
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace !important; /* Force markdown output and descendants to stay monospace, FTW! */
      }

      .chat-body.markdown.markdown-body {
        max-width: 100%;
        padding: 0;
      }

      .chat-body.markdown h1,
      .chat-body.markdown h2,
      .chat-body.markdown h3,
      .chat-body.markdown h4,
      .chat-body.markdown h5,
      .chat-body.markdown h6,
      .chat-body.markdown p,
      .chat-body.markdown li,
      .chat-body.markdown pre,
      .chat-body.markdown code {
        font-size: 1.2rem;
        line-height: 1.6;
      }

      .chat-body pre {
        background-color: #2c3e50;
        border: 1px solid #dfe2e5;
        border-radius: 4px;
        padding: 2px;
        overflow-x: auto;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        white-space: pre;
      }

      .chat-body code {
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        font-size: 0.95rem;
        background-color: rgba(27,31,35,0.05);
        padding: 2px 4px;
        border-radius: 4px;
      }
    
      /* 6. Composer Row (inside User fieldset) */
      .composer {
        display: flex;
        gap: 10px;
        align-items: stretch;
      }

      .chat-turn.user:focus-within {
        background-color: #ECF1F8;
        border-left-width: 5px;
        border-left-style: solid;
        border-color: #d1d9e6;
        color: #2c3e50;
      }

      .composer-main {
        flex: 1;
        display: flex;
        flex-direction: column;
        gap: 8px;
      }

      .composer textarea {
        flex: 0 0 auto;
        resize: none;
        border: none;
        background: transparent;
        padding: 0;
        margin: 0;
        outline: none;
        overflow-y: hidden;
      }

      .attachment-row {
        display: flex;
        align-items: center;
        gap: 10px;
        font-size: 0.95rem;
        color: #2c3e50;
        position: relative;
      }

      .attachment-row.has-attachment .attachment-clear {
        display: inline-flex;
      }
      .attachment-row.has-attachment .attachment-button {
        display: none;
      }
      .attachment-row.has-attachment .search-button {
        display: none;
      }
      .attachment-row.search-mode .attachment-button,
      .attachment-row.search-mode .search-button {
        display: none;
      }
      .attachment-row.search-mode .attachment-clear {
        display: inline-flex;
      }
      .attachment-row.loading .attachment-filename {
        font-style: italic;
        color: #6b7a90;
      }

      .attachment-button,
      .search-button,
      .copy-button,
      .user-trim-button {
        width: 24px;
        height: 24px;
        border: none;
        border-radius: 4px;
        background: #d1d9e6;
        color: #2c3e50;
        font-weight: 800;
        font-size: 1.25rem;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s;
      }

      .attachment-button:hover,
      .search-button:hover,
      .copy-button:hover,
      .user-trim-button:hover {
        background: #c3cbd9;
      }

      .attachment-filename {
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        font-size: 0.95rem;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }

      .attachment-clear {
        display: none;
        width: 24px;
        height: 24px;
        border: none;
        border-radius: 4px;
        background: #d1d9e6;
        color: #2c3e50;
        font-weight: 800;
        font-size: 1.25rem;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s, color 0.15s;
      }

      .attachment-clear:hover {
        background: #c3cbd9;
      }

      .copy-button,
      .user-trim-button {
        position: absolute;
        top: -6px;
        right: 8px;
      }

      .user-delete-button {
        position: absolute;
        top: -6px;
        right: 36px;
        width: 24px;
        height: 24px;
        border: none;
        border-radius: 4px;
        background: #d1d9e6;
        color: #2c3e50;
        font-weight: 800;
        font-size: 1.1rem;
        display: flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s, color 0.15s;
      }

      .user-delete-button:hover {
        background: #c3cbd9;
      }

      .hidden-file-input {
        position: absolute;
        left: -9999px;
        width: 1px;
        height: 1px;
        opacity: 0;
        pointer-events: none;
      }

      .attachment-chip {
        position: relative;
        display: inline-flex;
        align-items: center;
        gap: 6px;
        margin-top: 8px;
        padding: 6px 10px;
        background: #d1d9e6;
        color: #2c3e50;
        border-radius: 4px;
        border: 1px solid #c3cbd9;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        cursor: pointer;
        transition: background 0.15s, border-color 0.15s;
      }

      .attachment-chip:hover,
      .attachment-chip:focus {
        background: #c3cbd9;
        outline: none;
      }

      .attachment-chip .glyphicon {
        font-size: 1.05rem;
      }

      .attachment-chip .attachment-label {
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        max-width: 260px;
      }

      .attachment-preview {
        position: absolute;
        z-index: 30;
        min-width: 280px;
        max-width: 420px;
        background: #ffffff;
        color: #2c3e50;
        border: 1px solid #c3cbd9;
        box-shadow: 3px 3px 0px rgba(0,0,0,0.15);
        border-radius: 6px;
        padding: 10px 12px;
        top: calc(100% + 6px);
        left: 0;
      }

      .attachment-preview.above {
        top: auto;
        bottom: calc(100% + 6px);
      }

      .attachment-preview h5 {
        margin: 0 0 6px 0;
        font-size: 1rem;
        font-weight: 700;
      }

      .attachment-preview .meta {
        font-size: 0.9rem;
        margin-bottom: 8px;
        color: #40536a;
      }

      .attachment-preview .preview-body {
        max-height: 240px;
        overflow: auto;
        background: #f5f7fb;
        border: 1px solid #e0e6f0;
        padding: 8px;
        border-radius: 4px;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        font-size: 1rem;
        white-space: pre-wrap;
      }

      .attachment-preview .preview-image {
        max-width: 100%;
        max-height: 220px;
        display: block;
        margin: 0 auto;
        border: 1px solid #e0e6f0;
        border-radius: 4px;
        background: #fff;
      }

      .attachment-preview .actions {
        display: flex;
        gap: 6px;
        margin-top: 8px;
        flex-wrap: wrap;
      }

      .attachment-preview .preview-action {
        display: inline-flex;
        align-items: center;
        gap: 4px;
        padding: 4px 8px;
        border: 1px solid #c3cbd9;
        background: #d1d9e6;
        color: #2c3e50;
        border-radius: 4px;
        cursor: pointer;
        font-size: 0.9rem;
        transition: background 0.15s, border-color 0.15s;
      }

      .attachment-preview .preview-action:hover {
        background: #c3cbd9;
      }

      .attachment-preview .preview-note {
        margin-top: 6px;
        font-size: 0.85rem;
        color: #6b7a90;
      }

      .attachment-modal-backdrop {
        position: fixed;
        inset: 0;
        background: rgba(0,0,0,0.65);
        z-index: 50;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 20px;
      }

      .attachment-modal {
        background: #ffffff;
        color: #2c3e50;
        max-width: 800px;
        width: 100%;
        max-height: 90vh;
        border-radius: 8px;
        box-shadow: 4px 4px 0px rgba(0,0,0,0.2);
        padding: 16px;
        overflow: hidden;
        position: relative;
      }

      .attachment-modal .modal-header {
        display: flex;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 10px;
      }

      .attachment-modal .modal-header h4 {
        margin: 0;
        font-size: 1.1rem;
      }

      .attachment-modal .modal-body {
        max-height: 70vh;
        overflow: auto;
        border: 1px solid #e0e6f0;
        border-radius: 4px;
        padding: 10px;
        background: #f5f7fb;
      }

      .attachment-modal .modal-body pre {
        margin: 0;
        white-space: pre-wrap;
        word-break: break-word;
      }

      .attachment-modal .close-modal {
        border: none;
        background: #d1d9e6;
        color: #2c3e50;
        border-radius: 4px;
        width: 28px;
        height: 28px;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        cursor: pointer;
      }
    
      /* 7. The Button - Industrial Action */
      .composer .btn {
        align-self: stretch; 
        padding: 0 24px;
        border: 2px solid #555; 
        background: #333; 
        color: #fff;
        font-family: 'Roboto Mono', 'Menlo', 'Consolas', monospace;
        font-weight: bold;
        text-transform: uppercase;
        cursor: pointer;
        transition: all 0.2s;
        display: flex;
        align-items: center;
      }
    
      .composer .btn:hover {
        background: #0056b3;
        border-color: #0056b3;
      }
      
      .composer .btn:active {
        background: #004494;
      }
    
      @media (max-width: 900px) {
        .composer {
          flex-direction: column;
        }
        .composer .btn {
          width: 100%;
          border: 2px solid #555;
        }
      }

      .clear-chat-row {
        display: flex;
        visibility: hidden;
        pointer-events: none;
        margin-top: 12px;
        gap: 10px;
        align-items: center;
      }

      .clear-chat-row .btn {
        display: inline-flex;
        align-items: center;
        gap: 6px;
      }
    </style>
  </head>
  <body id="IndexControl">
	#(topmenu)#
      #%env/templates/embeddedheader.template%#
    ::
      #%env/templates/simpleheader.template%#
	::
      #%env/templates/header.template%#
	  #%env/templates/submenuAI.template%#
    #(/topmenu)#

    <h2>YaCy Chat</h2>
    <p>Chat with your configured LLM using the local YaCy settings. Requests are sent to the same host that served this page.</p>

      <div class="chat-panel">
        <div class="chat-flow">
          <div class="chat-messages" id="chatMessages" aria-live="polite"></div>
          <form id="chatForm">
            <fieldset class="chat-turn user">
              <legend>User</legend>
              <div class="composer">
                <div class="composer-main" id="composerMain">
                  <textarea class="chat-body" id="userInput" rows="3" placeholder="Ask me anything..." required="required"></textarea>
                  <div class="attachment-row" id="attachmentRow">
                    <button type="button" class="search-button" id="searchButton" aria-label="Search">
                      <span class="glyphicon glyphicon-search" aria-hidden="true"></span>
                    </button>
                    <button type="button" class="attachment-button" id="addFileButton" aria-label="Attach a file">
                      <span class="glyphicon glyphicon-paperclip" aria-hidden="true"></span>
                    </button>
                    <button type="button" class="attachment-clear" id="clearFileButton" aria-label="Remove attached file">
                      <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
                    </button>
                    <span class="attachment-filename" id="attachmentFilename">Attach PNG/JPG or text (.txt/.md/.tex)</span>
                    <input type="file" id="fileInput" class="hidden-file-input" accept="image/png,image/jpeg,text/plain,text/markdown,text/x-tex,application/x-tex,application/x-latex,text/*,.png,.jpg,.jpeg,.txt,.md,.markdown,.tex"/>
                  </div>
                </div>
                <input type="submit" class="btn btn-primary" id="sendButton" value="Send"/>
              </div>
            </fieldset>
          </form>
          <div class="clear-chat-row" id="clearChatRow">
            <button type="button" class="btn btn-inverse label-warning" id="clearChatButton" aria-label="Clear chat">
              <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
              Clear Chat
            </button>
            <button type="button" class="btn btn-inverse label-success" id="downloadChatButton" aria-label="Download chat">
              <span class="glyphicon glyphicon-download" aria-hidden="true"></span>
              Download Chat
            </button>
            <button type="button" class="btn btn-inverse label-primary" id="uploadChatButton" aria-label="Upload chat">
              <span class="glyphicon glyphicon-upload" aria-hidden="true"></span>
              Upload Chat
            </button>
            <input type="file" id="uploadChatInput" class="hidden-file-input" accept="application/json"/>
            <button type="button" class="btn btn-inverse label-info" id="toggleSystemButton" aria-label="Show system prompt">
              <span class="glyphicon glyphicon-star" aria-hidden="true"></span>
              <span class="toggle-label">Show System</span>
            </button>
          </div>
        </div>
      </div>

    <script src="js/highlight.min.js"></script>
    <script src="js/marked.umd.js"></script>
    <script src="js/index.umd.min.js"></script>
    <script type="text/javascript">
    const SYSTEM_PROMPT = '#[system_prompt]#';
    const defaultApiHost = '';

    const STORAGE_KEY = 'yacychat_recent_pairs';

    const state = {
        messages: [],
        config: {
            apiHost: defaultApiHost,
            model: 'chat',
            systemPrompt: SYSTEM_PROMPT
        },
        busy: false,
        attachment: null,
        searchMode: false,
        assistantSeen: false,
        showSystem: false
    };

    const dom = {
        messages: document.getElementById('chatMessages'),
        form: document.getElementById('chatForm'),
        input: document.getElementById('userInput'),
        sendButton: document.getElementById('sendButton'),
        fileInput: document.getElementById('fileInput'),
        searchButton: document.getElementById('searchButton'),
        addFileButton: document.getElementById('addFileButton'),
        attachmentFilename: document.getElementById('attachmentFilename'),
        clearFileButton: document.getElementById('clearFileButton'),
        attachmentRow: document.getElementById('attachmentRow'),
        clearChatRow: document.getElementById('clearChatRow'),
        clearChatButton: document.getElementById('clearChatButton'),
        downloadChatButton: document.getElementById('downloadChatButton'),
        uploadChatButton: document.getElementById('uploadChatButton'),
        uploadChatInput: document.getElementById('uploadChatInput'),
        toggleSystemButton: document.getElementById('toggleSystemButton'),
        composerMain: document.getElementById('composerMain')
    };

    const IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg']);
    const TEXT_MIME_TYPES = new Set(['text/plain', 'text/markdown', 'text/x-tex', 'application/x-tex', 'application/x-latex']);
    const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);
    const TEXT_EXTENSIONS = new Set(['.txt', '.md', '.markdown', '.tex']);
    const ALLOWED_MIME_TYPES = new Set([...IMAGE_MIME_TYPES, ...TEXT_MIME_TYPES, 'text/*']);
    const ALLOWED_EXTENSIONS = new Set([...IMAGE_EXTENSIONS, ...TEXT_EXTENSIONS]);
    const FILE_ACCEPT_TYPES = [...ALLOWED_MIME_TYPES, ...ALLOWED_EXTENSIONS].join(',');

    if (dom.fileInput) {
        dom.fileInput.setAttribute('accept', FILE_ACCEPT_TYPES);
    }

    if (dom.attachmentFilename) {
        dom.attachmentFilename.tabIndex = 0;
        setupAttachmentPreviewTrigger(dom.attachmentFilename, () => state.attachment);
    }

    if (typeof hljs !== 'undefined') {
        hljs.configure({ ignoreUnescapedHTML: true });
    }

    const languageAlias = {
        js: 'javascript',
        jsx: 'javascript',
        ts: 'typescript',
        tsx: 'typescript',
        sh: 'bash',
        shell: 'bash',
        bash: 'bash',
        csharp: 'csharp',
        'c#': 'csharp',
        'c++': 'cpp',
        py: 'python',
        rb: 'ruby',
        rs: 'rust',
        md: 'markdown',
        yml: 'yaml'
    };

    const languagePromises = {};

    function normalizeLanguage(lang) {
        if (!lang || typeof lang !== 'string') return '';
        const key = lang.trim().toLowerCase();
        return languageAlias[key] || key;
    }

    function ensureLanguage(lang) {
        if (typeof hljs === 'undefined') return Promise.resolve(false);
        const normalized = normalizeLanguage(lang);
        if (!normalized) return Promise.resolve(false);
        if (hljs.getLanguage(normalized)) return Promise.resolve(true);
        if (languagePromises[normalized]) return languagePromises[normalized];
        const loadScript = path => new Promise(resolve => {
            const script = document.createElement('script');
            script.src = path;
            script.async = true;
            script.onload = () => resolve(!!hljs.getLanguage(normalized));
            script.onerror = () => {
                console.warn('Failed to load highlight.js language file:', path);
                resolve(false);
            };
            document.head.appendChild(script);
        });
        const base = (window.location && window.location.origin) ? window.location.origin : '';
        const minSrc = `${base}/js/languages/${normalized}.min.js`;
        const fullSrc = `${base}/js/languages/${normalized}.js`;
        languagePromises[normalized] = loadScript(minSrc).then(success => {
            if (success) return true;
            return loadScript(fullSrc);
        }).then(success => success || !!hljs.getLanguage(normalized));
        return languagePromises[normalized];
    }

    function rehighlightCode(container) {
        if (!container || typeof hljs === 'undefined') return;
        const codeBlocks = Array.from(container.querySelectorAll('pre code'));
        if (!codeBlocks.length) return;
        const tasks = codeBlocks
            .map(block => Array.from(block.classList).find(cls => cls.startsWith('language-')))
            .map(match => match ? normalizeLanguage(match.replace(/^language-/, '')) : '')
            .filter(Boolean)
            .map(lang => ensureLanguage(lang));
        Promise.all(tasks).then(() => {
            codeBlocks.forEach(block => {
                try {
                    hljs.highlightElement(block);
                } catch (err) {
                    // ignore highlight failures
                }
            });
        });
    }

    const markdownSupport = (() => {
        if (typeof marked === 'undefined') {
            return { enabled: false };
        }
        try {
            if (window.markedHighlight && typeof window.markedHighlight.markedHighlight === 'function' && typeof hljs !== 'undefined') {
                marked.use(window.markedHighlight.markedHighlight({
                    langPrefix: 'hljs language-',
                    highlight: (code, lang) => {
                        const language = normalizeLanguage(lang);
                        ensureLanguage(language);
                        if (language && hljs.getLanguage(language)) {
                            return hljs.highlight(code, { language }).value;
                        }
                        // kick off lazy load; will rehighlight after render
                        return escapeHTML(code);
                    }
                }));
            }
        } catch (err) {
            console.warn('Failed to initialize syntax highlighting', err);
        }
        marked.setOptions({
            gfm: true,
            breaks: true,
            smartLists: true,
            mangle: false,
            headerIds: false
        });
        return { enabled: true };
    })();

    function escapeHTML(value) {
        const div = document.createElement('div');
        div.textContent = value || '';
        return div.innerHTML;
    }

    function sanitizeHTML(html) {
        if (!html) return '';
        const template = document.createElement('template');
        template.innerHTML = html;
        const blockedTags = new Set(['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta']);
        const walker = document.createTreeWalker(template.content, NodeFilter.SHOW_ELEMENT, null);
        const toRemove = [];
        while (walker.nextNode()) {
            const el = walker.currentNode;
            if (!el || !el.tagName) continue;
            const tag = el.tagName.toLowerCase();
            if (blockedTags.has(tag)) {
                toRemove.push(el);
                continue;
            }
            for (const attr of Array.from(el.attributes)) {
                const name = attr.name.toLowerCase();
                const value = attr.value || '';
                if (name.startsWith('on')) {
                    el.removeAttribute(attr.name);
                    continue;
                }
                if ((name === 'href' || name === 'src') && /^\s*javascript:/i.test(value)) {
                    el.removeAttribute(attr.name);
                }
            }
        }
        toRemove.forEach(node => node.remove());
        return template.innerHTML;
    }

    function renderMarkdown(content) {
        const source = typeof content === 'string' ? content : '';
        if (!markdownSupport.enabled || typeof marked === 'undefined') {
            return escapeHTML(source);
        }
        try {
            return sanitizeHTML(marked.parse(source));
        } catch (err) {
            console.warn('Markdown rendering failed', err);
            return escapeHTML(source);
        }
    }

    function applyMessageContent(target, role, text) {
        if (!target) return;
        if (role === 'assistant') {
            target.classList.add('markdown', 'markdown-body');
            target.classList.remove('plain-text');
            target.innerHTML = renderMarkdown(text);
            rehighlightCode(target);
        } else {
            target.classList.add('plain-text');
            target.classList.remove('markdown');
            target.textContent = text || '';
        }
    }

    function scrollToBottom(smooth = true) {
        window.requestAnimationFrame(() => {
            const behavior = smooth ? 'smooth' : 'auto';
            window.scrollTo({ top: document.documentElement.scrollHeight, behavior });
        });
    }

    function formatTimestamp() {
        const now = new Date();
        const pad = (n, len = 2) => n.toString().padStart(len, '0');
        return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
    }

    function updateSystemToggleButton() {
        if (!dom.toggleSystemButton) return;
        const icon = dom.toggleSystemButton.querySelector('.glyphicon');
        if (icon) {
            icon.className = `glyphicon ${state.showSystem ? 'glyphicon-star-empty' : 'glyphicon-star'}`;
        }
        const label = state.showSystem ? 'Hide System' : 'Show System';
        dom.toggleSystemButton.querySelector('.toggle-label').textContent = label;
    }

    function appendMessage(role, text, opts = {}) {
        const { skipScroll = false, skipVisibilityUpdate = false, attachment = null, messageIndex = undefined } = opts;
        const entry = document.createElement('fieldset');
        entry.className = `chat-turn ${role}`;
        if (role === 'assistant' || role === 'user') {
            entry.style.position = 'relative';
        }
        const legend = document.createElement('legend');
        if (role === 'assistant') {
            legend.textContent = 'Assistant';
        } else if (role === 'user') {
            legend.textContent = 'User';
        } else {
            legend.textContent = 'System';
        }
        entry.appendChild(legend);

        const body = document.createElement('div');
        body.className = 'chat-body';
        applyMessageContent(body, role, text);
        entry.appendChild(body);

        if (attachment && role === 'user') {
            const attachmentContainer = document.createElement('div');
            attachmentContainer.appendChild(createAttachmentChip(attachment));
            entry.appendChild(attachmentContainer);
        }

        dom.messages.appendChild(entry);
        if (typeof opts.messageIndex === 'number') {
            entry.dataset.messageIndex = String(opts.messageIndex);
        }
        if (role === 'assistant') {
            const copyBtn = document.createElement('button');
            copyBtn.type = 'button';
            copyBtn.className = 'copy-button';
            copyBtn.title = 'Copy answer';
            copyBtn.innerHTML = '<span class="glyphicon glyphicon-copy" aria-hidden="true"></span>';
            copyBtn.addEventListener('click', () => copyAssistant(body.textContent));
            entry.appendChild(copyBtn);
        }
        if (role === 'user' && typeof messageIndex === 'number') {
            const trimBtn = document.createElement('button');
            trimBtn.type = 'button';
            trimBtn.className = 'user-trim-button';
            trimBtn.title = 'Reuse from here';
            trimBtn.innerHTML = '<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>';
            trimBtn.addEventListener('click', () => trimConversationFromIndex(messageIndex, body.textContent));
            entry.appendChild(trimBtn);
            const deleteBtn = document.createElement('button');
            deleteBtn.type = 'button';
            deleteBtn.className = 'user-delete-button';
            deleteBtn.title = 'Delete this turn';
            deleteBtn.innerHTML = '<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>';
            deleteBtn.addEventListener('click', () => deleteConversationPair(messageIndex));
            entry.appendChild(deleteBtn);
        }
        if (!skipScroll && !state.busy) {
            scrollToBottom();
        }
        if (!skipVisibilityUpdate && role === 'assistant') {
            state.assistantSeen = true;
            updateClearChatVisibility();
        }
        return body;
    }

    function clearAttachment() {
        state.attachment = null;
        state.searchMode = false;
        dom.fileInput.value = '';
        dom.attachmentFilename.textContent = 'Attach PNG/JPG or text (.txt/.md/.tex)';
        dom.attachmentRow.classList.remove('has-attachment');
        dom.attachmentRow.classList.remove('search-mode');
        closeAttachmentPopover();
    }

    function setComposerAttachment(attachment) {
        state.searchMode = false;
        dom.attachmentRow.classList.remove('search-mode');
        state.attachment = attachment ? cloneAttachment(attachment) : null;
        if (state.attachment) {
            dom.attachmentFilename.textContent = `Attach File: ${state.attachment.name || 'Attachment'}`;
            dom.attachmentRow.classList.add('has-attachment');
        } else {
            clearAttachment();
        }
    }

    function setAttachmentLoading(isLoading) {
        if (!dom.attachmentRow || !dom.attachmentFilename) return;
        dom.attachmentRow.classList.toggle('loading', !!isLoading);
        if (isLoading) {
            dom.attachmentFilename.textContent = 'Loading attachment...';
        } else if (!state.attachment) {
            dom.attachmentFilename.textContent = 'Attach PNG/JPG or text (.txt/.md/.tex)';
        }
    }

    async function copyAssistant(text) {
        try {
            await navigator.clipboard.writeText(text || '');
        } catch (err) {
            console.warn('Clipboard copy failed', err);
        }
    }

    function trimConversationFromIndex(index, reuseText) {
        if (typeof index !== 'number' || index < 0) return;
        const targetMessage = state.messages[index];
        state.messages = state.messages.slice(0, index);
        state.assistantSeen = state.messages.some(m => m.role === 'assistant');
        state.showSystem = false;
        renderConversation();
        persistConversation();
        updateClearChatVisibility();
        updateSystemToggleButton();
        setComposerAttachment(targetMessage?.attachment || null);
        dom.input.value = reuseText || '';
        resizeComposer();
        showComposer();
    }

    function deleteConversationPair(userIndex) {
        if (typeof userIndex !== 'number' || userIndex < 0) return;
        if (!state.messages[userIndex] || state.messages[userIndex].role !== 'user') return;
        const nextMessage = state.messages[userIndex + 1];
        const removeCount = nextMessage && nextMessage.role === 'assistant' ? 2 : 1;
        state.messages.splice(userIndex, removeCount);
        state.assistantSeen = state.messages.some(m => m.role === 'assistant');
        renderConversation();
        persistConversation();
        updateClearChatVisibility();
        updateSystemToggleButton();
        showComposer();
    }

    function hideComposer() {
        if (dom.form) {
            dom.form.style.visibility = 'hidden';
            dom.form.style.pointerEvents = 'none';
        }
    }

    function showComposer() {
        if (dom.form) {
            dom.form.style.visibility = '';
            dom.form.style.pointerEvents = '';
        }
        if (dom.input) {
            try {
                dom.input.focus({ preventScroll: true });
            } catch (err) {
                dom.input.focus();
            }
        }
    }

    function updateClearChatVisibility() {
        if (!dom.clearChatRow) return;
        dom.clearChatRow.style.visibility = state.assistantSeen ? 'visible' : 'hidden';
        dom.clearChatRow.style.pointerEvents = state.assistantSeen ? 'auto' : 'none';
    }

    function ensureComposerVisible(evt) {
        if (evt && evt.isTrusted === false) return;
        scrollToBottom(true);
    }

    function clearChatHistory() {
        dom.messages.innerHTML = '';
        state.messages = [];
        state.assistantSeen = false;
        state.showSystem = false;
        localStorage.removeItem(STORAGE_KEY);
        closeAttachmentPopover();
        closeAttachmentModal();
        updateClearChatVisibility();
        updateSystemToggleButton();
        dom.input.value = '';
        clearAttachment();
        resizeComposer();
    }

    function downloadChat() {
        const payload = buildRequestPayload();
        const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const link = document.createElement('a');
        link.href = url;
        link.download = `yacychat-${formatTimestamp()}.json`;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
        URL.revokeObjectURL(url);
    }

    function applyUploadedChat(payload) {
        if (!payload || !Array.isArray(payload.messages)) {
            appendMessage('system', 'Invalid chat file.');
            return;
        }
        clearChatHistory();
        if (payload.model) {
            state.config.model = payload.model;
        }
        for (const msg of payload.messages) {
            if (!msg?.role || msg.content === undefined) continue;
            state.messages.push({ role: msg.role, content: msg.content });
        }
        renderConversation();
        persistConversation();
    }

    async function handleUploadChatFile(event) {
        const file = event.target.files && event.target.files[0];
        dom.uploadChatInput.value = '';
        if (!file) return;
        try {
            const text = await file.text();
            const parsed = JSON.parse(text);
            applyUploadedChat(parsed);
        } catch (err) {
            appendMessage('system', `Failed to load chat file: ${err.message}`);
        }
    }

    function ensureSystemMessage() {
        if (!state.messages.length || state.messages[0].role !== 'system') {
            state.messages.unshift({ role: 'system', content: state.config.systemPrompt });
        }
    }

    async function streamChat(userMessage, assistantNode, options = {}) {
        const { onFirstToken, includeUserInPayload = true, pushUserToState = true } = options;
        ensureSystemMessage();
        const sanitizedMessages = state.messages.map(stripMessageForApi).filter(Boolean);
        const sanitizedUserMessage = includeUserInPayload ? stripMessageForApi(userMessage) : null;
        const payload = {
            model: state.config.model,
            messages: sanitizedUserMessage ? [...sanitizedMessages, sanitizedUserMessage] : sanitizedMessages,
            stream: true
        };
        const headers = { 'Content-Type': 'application/json' };
        const response = await fetch(state.config.apiHost.replace(/\/$/, '') + '/v1/chat/completions', {
            method: 'POST',
            headers,
            body: JSON.stringify(payload)
        });
        if (!response.ok) {
            if (response.status === 429) {
                throw new Error('Rate limit reached: please wait and try again. The server is protecting itself from overload.');
            }
            throw new Error(`Request failed: ${response.status} ${response.statusText}`);
        }
        if (!response.body) {
            throw new Error('Streaming not supported by this browser.');
        }
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8');
        let assistantText = '';
        let sawFirstDelta = false;
        let focusShown = false;
        let searchApplied = false;
        if (typeof onFirstToken === 'function' && !focusShown) {
            focusShown = true;
            onFirstToken();
        }
        let autoScroll = true;
        let pending = '';

        const applySearchAttachment = parsed => {
            if (searchApplied) return;
            if (!parsed) return;
            const searchName = parsed['search-filename'];
            const searchBase64 = parsed['search-text-base64'];
            if (!searchName || !searchBase64) return;
            const dataUrl = `data:text/markdown;base64,${searchBase64}`;
            const textContent = base64ToUtf8(searchBase64);
            for (let i = state.messages.length - 1; i >= 0; i--) {
                const msg = state.messages[i];
                if (msg && msg.role === 'user' && msg.search) {
                    msg.attachment = {
                        kind: 'text',
                        name: searchName,
                        dataUrl,
                        mime: 'text/markdown',
                        textContent
                    };
                    delete msg.search;
                    searchApplied = true;
                    break;
                }
            }
        };

        const performAutoScroll = () => {
            if (!autoScroll) return;
            scrollToBottom(false);
            const rect = assistantNode?.getBoundingClientRect();
            if (rect && rect.top <= 110) {
                autoScroll = false;
            }
        };

        const processLine = line => {
            const trimmed = (line || '').trim();
            if (!trimmed) return false;
            if (trimmed === 'data: [DONE]' || trimmed === '[DONE]') return true;
            if (!trimmed.startsWith('data:')) return false;
            const clean = trimmed.replace(/^data:\s*/i, '');
            if (!clean) return false;
            try {
                const parsed = JSON.parse(clean);
                applySearchAttachment(parsed);
                const delta = parsed?.choices?.[0]?.delta?.content;
                if (delta) {
                    if (!sawFirstDelta) {
                        sawFirstDelta = true;
                        applyMessageContent(assistantNode, 'assistant', '');
                        if (!focusShown && typeof onFirstToken === 'function') {
                            focusShown = true;
                            onFirstToken();
                        }
                    }
                    assistantText += delta;
                    applyMessageContent(assistantNode, 'assistant', assistantText);
                    performAutoScroll();
                }
            } catch (err) {
                console.warn('Failed to parse line', trimmed, err);
            }
            return false;
        };

        const parseChunk = chunk => {
            pending += chunk;
            const lines = pending.split(/\r?\n/);
            pending = lines.pop() || '';
            for (const line of lines) {
                if (processLine(line)) return true;
            }
            return false;
        };
        let done = false;
        while (!done) {
            const { value, done: readerDone } = await reader.read();
            if (readerDone) break;
            const chunk = decoder.decode(value, { stream: true });
            if (!focusShown && chunk && chunk.trim() && typeof onFirstToken === 'function') {
                focusShown = true;
                onFirstToken();
            }
            done = parseChunk(chunk);
        }
        if (!done && pending.trim()) {
            parseChunk('\n');
        }
        if (!focusShown && typeof onFirstToken === 'function') {
            focusShown = true;
            onFirstToken();
        }
        if (pushUserToState) {
            state.messages.push(userMessage);
        }
        state.messages.push({ role: 'assistant', content: assistantText });
        state.assistantSeen = true;
        persistConversation();
        updateClearChatVisibility();
        updateSystemToggleButton();
        renderConversation({ skipScroll: true });
    }

    function resizeComposer() {
        const minHeight = 24;
        dom.input.style.height = 'auto';
        const measured = dom.input.scrollHeight || minHeight;
        dom.input.style.height = `${Math.max(minHeight, measured)}px`;
    }

    function buildUserMessage(promptText) {
        if (state.attachment) {
            const parts = [{ type: 'text', text: promptText }];
            if (state.attachment.kind === 'image' && state.attachment.dataUrl) {
                parts.push({
                    type: 'image_url',
                    image_url: { url: state.attachment.dataUrl },
                    filename: state.attachment.name
                });
            } else if (state.attachment.kind === 'text' && state.attachment.dataUrl) {
                parts.push({
                    type: 'image_url',
                    image_url: { url: state.attachment.dataUrl },
                    filename: state.attachment.name
                });
            }
            return {
                role: 'user',
                content: parts,
                search: !!state.searchMode
            };
        }
        return { role: 'user', content: promptText, search: !!state.searchMode };
    }

    function stripMessageForApi(message) {
        if (!message) return null;
        const sanitized = { role: message.role, content: message.content };
        if (message.search) sanitized.search = true;
        return sanitized;
    }

    function buildRequestPayload() {
        ensureSystemMessage();
        return {
            model: state.config.model,
            messages: state.messages.map(stripMessageForApi).filter(Boolean),
            stream: true
        };
    }

    function messageText(content) {
        if (typeof content === 'string') return content;
        if (Array.isArray(content)) {
            const textPart = content.find(part => part?.type === 'text');
            return textPart?.text || '';
        }
        if (content && typeof content === 'object' && typeof content.text === 'string') {
            return content.text;
        }
        return '';
    }

    function renderConversation(options = {}) {
        const { skipScroll = false } = options;
        closeAttachmentPopover();
        dom.messages.innerHTML = '';
        state.assistantSeen = state.messages.some(m => m.role === 'assistant');
        for (let i = 0; i < state.messages.length; i++) {
            const msg = state.messages[i];
            if (msg.role === 'system' && !state.showSystem) continue;
            appendMessage(msg.role, messageText(msg.content), { skipScroll: true, skipVisibilityUpdate: true, messageIndex: i, attachment: msg.attachment });
        }
        updateClearChatVisibility();
        updateSystemToggleButton();
        if (!skipScroll && !state.busy) {
            scrollToBottom();
        }
    }

    function persistConversation() {
        try {
            const payload = {
                model: state.config.model,
                messages: state.messages
            };
            localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
        } catch (err) {
            console.warn('Failed to persist conversation', err);
        }
    }

    function hydrateConversation() {
        let stored = null;
        try {
            stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
        } catch (err) {
            console.warn('Failed to parse stored conversation', err);
        }
        if (Array.isArray(stored) && stored.length) {
            for (const pair of stored) {
                if (pair?.user) {
                    state.messages.push({ role: 'user', content: pair.user });
                }
                if (pair?.assistant) {
                    state.messages.push({ role: 'assistant', content: pair.assistant });
                }
            }
        } else if (stored && Array.isArray(stored.messages)) {
            state.messages = stored.messages.map(msg => ({ ...msg }));
            if (stored.model) {
                state.config.model = stored.model;
            }
        }
        ensureSystemMessage();
        renderConversation();
    }

    function formatUserPreview(promptText) {
        return promptText;
    }

    async function handleFileChange(event) {
        const file = event.target.files && event.target.files[0];
        dom.fileInput.value = '';
        if (!file) {
            clearAttachment();
            return;
        }
        try {
            setAttachmentLoading(true);
            const attachment = await buildAttachment(file);
            if (!attachment) {
                appendMessage('system', 'Please upload a PNG/JPG image or a text file (.txt, .md, .tex, or other plain text).');
                clearAttachment();
                return;
            }
            setComposerAttachment(attachment);
        } catch (err) {
            appendMessage('system', `Failed to read file: ${err.message}`);
            clearAttachment();
        } finally {
            setAttachmentLoading(false);
        }
    }

    function readFileAsDataURL(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = () => reject(reader.error);
            reader.readAsDataURL(file);
        });
    }

    function readFileAsText(file) {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result);
            reader.onerror = () => reject(reader.error);
            reader.readAsText(file);
        });
    }

    function textToDataUrl(text, mime = 'text/plain') {
        const safeMime = mime || 'text/plain';
        const encoded = btoa(unescape(encodeURIComponent(text || '')));
        return `data:${safeMime};base64,${encoded}`;
    }

    function base64ToUtf8(base64) {
        try {
            return decodeURIComponent(escape(atob(base64)));
        } catch (err) {
            return '';
        }
    }

    function getFileExtension(name) {
        if (!name) return '';
        const dot = name.lastIndexOf('.');
        return dot >= 0 ? name.slice(dot).toLowerCase() : '';
    }

    function isAllowedByMime(mime) {
        if (!mime) return false;
        return ALLOWED_MIME_TYPES.has(mime) || (mime.startsWith('text/') && ALLOWED_MIME_TYPES.has('text/*'));
    }

    function isAllowedByExtension(ext) {
        if (!ext) return false;
        return ALLOWED_EXTENSIONS.has(ext);
    }

    function detectAttachmentKind(mime, ext) {
        const lowerMime = mime || '';
        if (IMAGE_MIME_TYPES.has(lowerMime) || IMAGE_EXTENSIONS.has(ext)) return 'image';
        if (lowerMime.startsWith('text/') || TEXT_MIME_TYPES.has(lowerMime) || TEXT_EXTENSIONS.has(ext)) return 'text';
        return null;
    }

    function formatFileSize(size) {
        if (typeof size !== 'number' || size <= 0) return '';
        const units = ['B', 'KB', 'MB', 'GB'];
        let value = size;
        let unitIndex = 0;
        while (value >= 1024 && unitIndex < units.length - 1) {
            value /= 1024;
            unitIndex++;
        }
        return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
    }

    function cloneAttachment(src) {
        if (!src) return null;
        return {
            kind: src.kind,
            name: src.name,
            dataUrl: src.dataUrl || null,
            textContent: src.textContent || '',
            mime: src.mime || '',
            size: typeof src.size === 'number' ? src.size : null
        };
    }

    function buildTextPreview(text) {
        const maxChars = 1200;
        const maxLines = 20;
        const lines = (text || '').split(/\r?\n/);
        let snippet = lines.slice(0, maxLines).join('\n');
        let truncated = lines.length > maxLines;
        if (snippet.length > maxChars) {
            snippet = snippet.slice(0, maxChars);
            truncated = true;
        }
        return { snippet, truncated };
    }

    function makePreviewData(attachment) {
        if (!attachment) return null;
        const ext = getFileExtension(attachment.name);
        const isMarkdown = ext === '.md' || ext === '.markdown';
        const isTex = ext === '.tex';
        if (attachment.kind === 'image' && attachment.dataUrl) {
            return {
                kind: 'image',
                name: attachment.name,
                mime: attachment.mime || 'image/*',
                size: attachment.size,
                imageUrl: attachment.dataUrl,
                note: 'Image preview. Click to open larger view.'
            };
        }
        if (attachment.kind === 'text') {
            const { snippet, truncated } = buildTextPreview(attachment.textContent || '');
            return {
                kind: isMarkdown || isTex ? 'markdown' : 'text',
                name: attachment.name,
                mime: attachment.mime || 'text/plain',
                size: attachment.size,
                text: snippet,
                truncated,
                isMarkdown,
                isTex,
                note: truncated ? 'Preview truncated for brevity.' : 'Full text available.'
            };
        }
        return {
            kind: 'unknown',
            name: attachment.name,
            mime: attachment.mime || 'application/octet-stream',
            size: attachment.size,
            note: 'Preview not available. Download to view.'
        };
    }

    let activePopover = null;
    let popoverAnchor = null;
    let hidePopoverTimer = null;
    let activeModal = null;

    function closeAttachmentPopover() {
        if (activePopover && activePopover.parentNode) {
            activePopover.parentNode.removeChild(activePopover);
        }
        activePopover = null;
        popoverAnchor = null;
    }

    function scheduleHidePopover(delay = 120) {
        clearTimeout(hidePopoverTimer);
        hidePopoverTimer = setTimeout(() => {
            closeAttachmentPopover();
        }, delay);
    }

    function createActionButton(icon, label, handler) {
        const btn = document.createElement('button');
        btn.type = 'button';
        btn.className = 'preview-action';
        btn.innerHTML = `<span class="glyphicon ${icon}" aria-hidden="true"></span><span>${label}</span>`;
        btn.addEventListener('click', handler);
        return btn;
    }

    function downloadAttachment(attachment) {
        if (!attachment) return;
        try {
            const name = attachment.name || 'attachment';
            if (attachment.dataUrl) {
                const link = document.createElement('a');
                link.href = attachment.dataUrl;
                link.download = name;
                document.body.appendChild(link);
                link.click();
                document.body.removeChild(link);
                return;
            }
            const text = attachment.textContent || '';
            const blob = new Blob([text], { type: attachment.mime || 'text/plain' });
            const url = URL.createObjectURL(blob);
            const link = document.createElement('a');
            link.href = url;
            link.download = name;
            document.body.appendChild(link);
            link.click();
            document.body.removeChild(link);
            URL.revokeObjectURL(url);
        } catch (err) {
            console.warn('Download failed', err);
        }
    }

    async function copyToClipboard(text) {
        try {
            await navigator.clipboard.writeText(text || '');
        } catch (err) {
            console.warn('Clipboard copy failed', err);
        }
    }

    function openInNewTab(attachment) {
        if (!attachment) return;
        if (attachment.kind === 'image' && attachment.dataUrl) {
            const popup = window.open('', '_blank');
            if (!popup) return;
            const safeTitle = escapeHTML(attachment.name || 'Image');
            popup.document.write(`
                <html>
                  <head>
                    <title>${safeTitle}</title>
                    <style>
                      body { margin: 0; display: flex; align-items: center; justify-content: center; background: #111; }
                      img { max-width: 100vw; max-height: 100vh; }
                    </style>
                  </head>
                  <body>
                    <img src="${attachment.dataUrl}" alt="${safeTitle}"/>
                  </body>
                </html>
            `);
            popup.document.close();
        } else if (attachment.kind === 'text') {
            const blob = new Blob([attachment.textContent || ''], { type: attachment.mime || 'text/plain' });
            const url = URL.createObjectURL(blob);
            window.open(url, '_blank');
            setTimeout(() => URL.revokeObjectURL(url), 2000);
        }
    }

    function closeAttachmentModal() {
        if (activeModal && activeModal.parentNode) {
            activeModal.parentNode.removeChild(activeModal);
        }
        activeModal = null;
    }

    function openAttachmentModal(preview, attachment) {
        closeAttachmentModal();
        if (!preview) return;
        const backdrop = document.createElement('div');
        backdrop.className = 'attachment-modal-backdrop';
        backdrop.addEventListener('click', event => {
            if (event.target === backdrop) closeAttachmentModal();
        });

        const modal = document.createElement('div');
        modal.className = 'attachment-modal';

        const header = document.createElement('div');
        header.className = 'modal-header';

        const title = document.createElement('h4');
        title.textContent = preview.name || 'Attachment';
        header.appendChild(title);

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'close-modal';
        closeBtn.innerHTML = '<span class="glyphicon glyphicon-remove" aria-hidden="true"></span>';
        closeBtn.addEventListener('click', closeAttachmentModal);
        header.appendChild(closeBtn);

        const body = document.createElement('div');
        body.className = 'modal-body';

        if (preview.kind === 'image' && preview.imageUrl) {
            const img = document.createElement('img');
            img.src = preview.imageUrl;
            img.alt = preview.name || 'Attachment image';
            img.style.maxWidth = '100%';
            img.style.display = 'block';
            body.appendChild(img);
        } else {
            const content = attachment?.textContent || preview.text || '';
            if (preview.kind === 'markdown' && markdownSupport.enabled) {
                const div = document.createElement('div');
                div.innerHTML = renderMarkdown(content);
                body.appendChild(div);
            } else {
                const pre = document.createElement('pre');
                pre.textContent = content;
                body.appendChild(pre);
            }
        }

        modal.appendChild(header);
        modal.appendChild(body);
        backdrop.appendChild(modal);
        document.body.appendChild(backdrop);
        activeModal = backdrop;

        window.addEventListener('keydown', function escHandler(event) {
            if (event.key === 'Escape') {
                closeAttachmentModal();
                window.removeEventListener('keydown', escHandler);
            }
        }, { once: true });
    }

    function renderPreviewBody(container, preview) {
        if (!container || !preview) return;
        if (preview.kind === 'image' && preview.imageUrl) {
            const img = document.createElement('img');
            img.src = preview.imageUrl;
            img.alt = preview.name || 'Attachment image';
            img.className = 'preview-image';
            container.appendChild(img);
            return;
        }
        const body = document.createElement('div');
        body.className = 'preview-body';
        if (preview.kind === 'markdown' && markdownSupport.enabled) {
            body.innerHTML = renderMarkdown(preview.text || '');
        } else {
            body.textContent = preview.text || 'Preview unavailable.';
        }
        container.appendChild(body);
    }

    function showAttachmentPopover(anchor, attachment) {
        if (!anchor || !attachment) return;
        const preview = makePreviewData(attachment);
        if (!preview) return;
        clearTimeout(hidePopoverTimer);
        closeAttachmentPopover();
        const host = anchor.parentElement || anchor;
        const hostStyle = window.getComputedStyle(host);
        if (hostStyle.position === 'static') {
            host.style.position = 'relative';
        }
        const popover = document.createElement('div');
        popover.className = 'attachment-preview';
        popover.setAttribute('role', 'dialog');
        const title = document.createElement('h5');
        title.textContent = preview.name || 'Attachment';
        popover.appendChild(title);

        const meta = document.createElement('div');
        meta.className = 'meta';
        const parts = [];
        if (preview.mime) parts.push(preview.mime);
        if (preview.size) parts.push(formatFileSize(preview.size));
        meta.textContent = parts.join(' • ');
        popover.appendChild(meta);

        renderPreviewBody(popover, preview);

        const actions = document.createElement('div');
        actions.className = 'actions';
        actions.appendChild(createActionButton('glyphicon-new-window', 'Full preview', () => openAttachmentModal(preview, attachment)));
        actions.appendChild(createActionButton('glyphicon-download', 'Download', () => downloadAttachment(attachment)));
        if (preview.kind === 'image') {
            actions.appendChild(createActionButton('glyphicon-picture', 'Open tab', () => openInNewTab(attachment)));
        }
        if (preview.kind === 'text' || preview.kind === 'markdown') {
            actions.appendChild(createActionButton('glyphicon-copy', 'Copy snippet', () => copyToClipboard(preview.text || '')));
            actions.appendChild(createActionButton('glyphicon-list-alt', 'Copy all', () => copyToClipboard(attachment.textContent || preview.text || '')));
        }
        popover.appendChild(actions);

        if (preview.note) {
            const note = document.createElement('div');
            note.className = 'preview-note';
            note.textContent = preview.note;
            popover.appendChild(note);
        }

        popover.addEventListener('mouseenter', () => clearTimeout(hidePopoverTimer));
        popover.addEventListener('mouseleave', () => scheduleHidePopover());
        host.appendChild(popover);

        const rect = anchor.getBoundingClientRect();
        const viewportMid = window.innerHeight / 2;
        const placeAbove = rect.top > viewportMid;
        if (placeAbove) {
            popover.classList.add('above');
        } else {
            popover.classList.remove('above');
        }

        activePopover = popover;
        popoverAnchor = anchor;
    }

    function setupAttachmentPreviewTrigger(anchor, attachmentProvider) {
        if (!anchor) return;
        const handler = () => {
            const attachment = typeof attachmentProvider === 'function' ? attachmentProvider() : attachmentProvider;
            if (!attachment) return;
            showAttachmentPopover(anchor, attachment);
        };
        anchor.addEventListener('mouseenter', handler);
        anchor.addEventListener('focus', handler);
        anchor.addEventListener('mouseleave', () => scheduleHidePopover());
        anchor.addEventListener('blur', () => scheduleHidePopover());
    }

    function createAttachmentChip(attachment) {
        const chip = document.createElement('div');
        chip.className = 'attachment-chip';
        chip.tabIndex = 0;
        const icon = document.createElement('span');
        const iconClass = attachment?.kind === 'image' ? 'glyphicon-picture' : (attachment?.kind === 'text' ? 'glyphicon-file' : 'glyphicon-paperclip');
        icon.className = `glyphicon ${iconClass}`;
        chip.appendChild(icon);
        const label = document.createElement('span');
        label.className = 'attachment-label';
        label.textContent = attachment?.name || 'Attachment';
        chip.appendChild(label);
        setupAttachmentPreviewTrigger(chip, () => attachment);
        return chip;
    }

    async function buildAttachment(file) {
        const name = file.name || 'attachment';
        const mime = (file.type || '').toLowerCase();
        const ext = getFileExtension(name);
        const allowed = isAllowedByMime(mime) || isAllowedByExtension(ext);
        const kind = detectAttachmentKind(mime, ext);
        if (!allowed || !kind) return null;
        if (kind === 'image') {
            const dataUrl = await readFileAsDataURL(file);
            return { kind: 'image', name, dataUrl, mime, size: file.size };
        }
        if (kind === 'text') {
            const textContent = await readFileAsText(file);
            const dataUrl = textToDataUrl(textContent, mime || 'text/plain');
            return { kind: 'text', name, textContent, dataUrl, mime, size: file.size };
        }
        return null;
    }

    dom.form.addEventListener('submit', async event => {
        event.preventDefault();
        if (state.busy) return;
        const prompt = dom.input.value.trim();
        if (!prompt) return;
        ensureSystemMessage();
        const userAttachment = cloneAttachment(state.attachment);
        const userMessage = buildUserMessage(prompt);
        if (userAttachment) {
            userMessage.attachment = userAttachment;
        }
        // add user message to state immediately so edit/trim is available right away
        state.messages.push({
            role: 'user',
            content: userMessage.content,
            attachment: userAttachment,
            search: !!state.searchMode
        });
        const userIndex = state.messages.length - 1;
        const preview = formatUserPreview(prompt);
        state.busy = true;
        dom.sendButton.disabled = true;
        appendMessage('user', preview, { attachment: userAttachment, messageIndex: userIndex });
        dom.input.value = '';
        clearAttachment();
        resizeComposer();
        const assistantNode = appendMessage('assistant', 'waiting for an answer...');
        try {
            await streamChat(userMessage, assistantNode, { onFirstToken: showComposer, includeUserInPayload: false, pushUserToState: false });
        } catch (err) {
            appendMessage('system', `Error: ${err.message}`);
            showComposer();
        } finally {
            state.busy = false;
            dom.sendButton.disabled = false;
            showComposer();
        }
    });

    dom.addFileButton.addEventListener('click', () => {
        dom.fileInput.click();
    });
    dom.searchButton?.addEventListener('click', () => {
        state.searchMode = true;
        state.attachment = null;
        dom.attachmentRow.classList.remove('has-attachment');
        dom.attachmentRow.classList.add('search-mode');
        dom.attachmentFilename.textContent = 'Attach Search Results';
    });
    dom.fileInput.addEventListener('change', handleFileChange);
    dom.clearFileButton.addEventListener('click', () => {
        clearAttachment();
    });
    dom.clearChatButton?.addEventListener('click', clearChatHistory);
    dom.downloadChatButton?.addEventListener('click', downloadChat);
    dom.uploadChatButton?.addEventListener('click', () => dom.uploadChatInput?.click());
    dom.uploadChatInput?.addEventListener('change', handleUploadChatFile);
    dom.toggleSystemButton?.addEventListener('click', () => {
        state.showSystem = !state.showSystem;
        renderConversation();
    });
    window.addEventListener('scroll', closeAttachmentPopover);

    dom.input.addEventListener('input', resizeComposer);
    dom.input.addEventListener('input', ensureComposerVisible);
    dom.input.addEventListener('keydown', event => {
        if (event.isComposing) return;
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            if (typeof dom.form.requestSubmit === 'function') {
                dom.form.requestSubmit(dom.sendButton);
            } else {
                dom.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
            }
        }
    });
    dom.composerMain?.addEventListener('dragover', event => {
        event.preventDefault();
    });
    dom.composerMain?.addEventListener('drop', async event => {
        event.preventDefault();
        const file = event.dataTransfer?.files && event.dataTransfer.files[0];
        if (!file) return;
        try {
            setAttachmentLoading(true);
            const attachment = await buildAttachment(file);
            if (!attachment) {
                appendMessage('system', 'Please drop a PNG/JPG image or a text file (.txt, .md, .tex, or other plain text).');
                return;
            }
            setComposerAttachment(attachment);
        } catch (err) {
            appendMessage('system', `Failed to read file: ${err.message}`);
            clearAttachment();
        } finally {
            setAttachmentLoading(false);
        }
    });
    window.addEventListener('resize', resizeComposer);

    hydrateConversation();
    updateSystemToggleButton();
    resizeComposer();
    updateClearChatVisibility();
    </script>

    #%env/templates/footer.template%#
  </body>
</html>
